` pattern.
// ---------------------------------------------------------------------------
/**
* Render an `` tag from an ACF image field, a fallback array, or a URL.
*
* @param mixed $image ACF image array (`['ID', 'url', 'alt', ...]`),
* a `['url', 'alt']` fallback array, or a URL string.
* @param string $size WP image size key (e.g. 'portfolio-card', 'hero-full').
* Only applies when an attachment ID is available.
* @param array $attrs HTML attributes. `loading` defaults to "lazy",
* `decoding` to "async". Pass `loading => "eager"` and
* `fetchpriority => "high"` for LCP images.
* @return string Escaped HTML — empty string when no image data is present.
*/
function cn_acf_image_tag( $image, string $size = 'full', array $attrs = array() ): string {
$attrs = array_merge(
array( 'loading' => 'lazy', 'decoding' => 'async' ),
$attrs
);
// Best path: WordPress-managed attachment with an ID. Produces a full
//
tag with srcset/sizes for responsive delivery.
if ( is_array( $image ) && ! empty( $image['ID'] ) ) {
return wp_get_attachment_image( (int) $image['ID'], $size, false, $attrs );
}
// Fallback paths: extract URL + alt, render a bare
.
$url = '';
$alt = (string) ( $attrs['alt'] ?? '' );
if ( is_array( $image ) && ! empty( $image['url'] ) ) {
$url = (string) $image['url'];
if ( '' === $alt && ! empty( $image['alt'] ) ) {
$alt = (string) $image['alt'];
}
} elseif ( is_string( $image ) && '' !== $image ) {
$url = $image;
}
if ( '' === $url ) {
return '';
}
$attrs['alt'] = $alt;
$attr_str = '';
foreach ( $attrs as $key => $val ) {
if ( null === $val || false === $val ) {
continue;
}
if ( true === $val ) {
$attr_str .= ' ' . esc_attr( $key );
continue;
}
$attr_str .= ' ' . esc_attr( $key ) . '="' . esc_attr( (string) $val ) . '"';
}
return '
';
}
// ---------------------------------------------------------------------------
// Page URL resolver
//
// Templates and partials previously hardcoded `home_url( '/contact' )`,
// `home_url( '/studio' )` etc. — fragile because the day someone renames a
// Page in WP admin (changes the slug), every footer/header link breaks.
//
// cn_url() resolves through get_page_by_path() first so it follows the
// actual configured permalink, then falls back to the conventional path.
// Optional second argument appends an anchor (#design, etc.).
//
// Internal cache avoids repeated DB lookups when the same slug is used
// across many partials in a single request.
// ---------------------------------------------------------------------------
/**
* Resolve the public URL for a theme page by slug, with optional anchor.
*
* @param string $slug Page slug (e.g. 'contact', 'studio'). No leading slash.
* @param string $anchor Optional anchor without the leading '#'.
* @return string URL — never empty; falls back to a conventional /{slug}/ path.
*/
function cn_url( string $slug, string $anchor = '' ): string {
static $cache = array();
$slug = trim( $slug, "/ \t\n\r\0\x0B" );
if ( '' === $slug ) {
return home_url( '/' );
}
if ( ! isset( $cache[ $slug ] ) ) {
$cache[ $slug ] = '';
$page = get_page_by_path( $slug );
if ( $page instanceof WP_Post ) {
$permalink = get_permalink( $page );
if ( $permalink ) {
$cache[ $slug ] = $permalink;
}
}
if ( '' === $cache[ $slug ] ) {
$cache[ $slug ] = home_url( '/' . $slug . '/' );
}
}
$anchor = ltrim( $anchor, '#' );
return $anchor ? $cache[ $slug ] . '#' . rawurlencode( $anchor ) : $cache[ $slug ];
}
/**
* Resolve the public URL for a WooCommerce product category by slug.
*
* @param string $slug Term slug (e.g. 'lechuza').
* @return string URL — never empty; always falls back to the conventional path.
*/
function cn_shop_category_url( string $slug ): string {
if ( '' === $slug ) {
return home_url( '/shop/' );
}
if ( taxonomy_exists( 'product_cat' ) ) {
$term = get_term_by( 'slug', $slug, 'product_cat' );
if ( $term && ! is_wp_error( $term ) ) {
$link = get_term_link( $term );
if ( ! is_wp_error( $link ) ) {
return $link;
}
}
}
return home_url( '/product-category/' . $slug . '/' );
}
// ---------------------------------------------------------------------------
// Disable comments site-wide
//
// Catapult Nature is a B2B studio site — posts are journal entries, not
// discussion threads. Disabling comments removes the surface entirely:
// - Comment support is stripped from every post type (no comment box).
// - Existing comments (if any imported with content) are forced closed and
// hidden from the front end.
// - Admin menu / dashboard widget / toolbar entries for Comments are hidden.
// - Comment feeds and the EditURI/RSD links are removed from